# author: Pete Michaud
# contact: michaudp@gmail.com

import pygame, random, math

#Constants

# Organisms grow as they reach maturity--this sets how small they are at birth,
# and how big they can become, in pixels
MIN_RADIUS = 3
MAX_RADIUS = 30

# The society of organisms begins with a randomly generated seed population of
# this size
INITIAL_POPULATION_SIZE   = 10

# Organisms die when their age is equal to or greater than their longevity.
# This sets the min and max ages at which organisms can die
MIN_LONGEVITY = 30
MAX_LONGEVITY = 120

# Attraction between organisms is calculated by how similar their attractiveness
# scores are. The larger the range, the less likely any two given organisms are
# to make a baby
MIN_ATTRACTIVENESS = 0
MAX_ATTRACTIVENESS = 3

# The Activity of an organism is used as a multiplier for how far it can travel
# during each generation
MIN_ACTIVE = 50
MAX_ACTIVE = 100

SCREEN_WIDTH   = 1024
SCREEN_HEIGHT   = 768

FRAMERATE = 40

# This is the number of pixels an organism can move during one
# generation (it's adjusted according to the organism's activity level
SPEED = 10

# This determines how many "years" pass per generation. It's compared to
# longevity
AGING_PER_GENERATION = 1

# Too many organisms on the field causes the computer to sputter and die, so
# limit the population size
MAX_POPULATION = 500

# Predefined colors in (red,green,blue) format
BACKGROUND_COLOR = (255,0,255)
TRANSPARENT_COLOR = (0, 0, 0)

class Organism(pygame.sprite.Sprite):
    ''' Represents an organism that can live, die, grow, move around, and mate
    with other organisms
    '''

    def __init__ ( self,
                   render_group,
                   # name, #name isn't really used, it's for debugging
                   longevity = -1,
                   attractive = -1,
                   activity = -1,
                   color = (-1, -1, -1),
                   pos = ( -1, -1 ) ):

        pygame.sprite.Sprite.__init__(self)

        self.render_group = render_group
        render_group.add(self)

        #self.name = name
        self.age = 0

        self.longevity = longevity
        self.attractiveness = attractive
        self.activity = activity
        self.color = color

        self.image      = pygame.Surface ( ( MAX_RADIUS * 2, MAX_RADIUS * 2) )
        # The colorkey of a pygame surface is the color that's transparent
        self.image.set_colorkey(TRANSPARENT_COLOR)

        pos = pos if pos != (-1,-1) else self.get_rect_pos( )

        self.rect = self.image.get_rect()
        self.rect.left = pos[0]
        self.rect.top = pos[1]


    # Property Getter and Setter Functions - these are used so I can say, for
    # example:
    # self.longevity = 50
    # and instead of just creating a reference called self.longevity to the
    # number 50, it would call the longevity setter function which automatically
    # generates a random value for it if you try to assign it -1
    @property
    def longevity(self):
        return self.__longevity

    @longevity.setter
    def longevity(self, value):
        self.__longevity = int( value \
            if value > -1 \
            else random.randint ( MIN_LONGEVITY, MAX_LONGEVITY ))

    @property
    def attractiveness(self):
        return self.__attractiveness

    @attractiveness.setter
    def attractiveness(self, value):
        self.__attractiveness = int (value \
            if value > -1 \
            else random.randint ( MIN_ATTRACTIVENESS, MAX_ATTRACTIVENESS ))

    @property
    def activity(self):
        return self.__activity

    @activity.setter
    def activity(self, value):
        self.__activity = int (value \
            if value > -1 \
            else random.randint ( MIN_ACTIVE, MAX_ACTIVE ))

    @property
    def color(self):
        return self.__color

    @color.setter
    def color(self, value):
        if len(value) != 3:
            AttributeError('Color must be a tuple of 3 integers')

        if value[0] > -1:
            #make sure the color value is legal (between 0 and 255)
            color = []
            for component in value:
                if component > 255:
                    color.append(255)
                elif component < 0:
                    color.append(0)
                else:
                    color.append(component)

            self.__color = tuple(color)

        else:
            self.__color = random.randint ( 0, 255 ), \
                           random.randint ( 0, 255 ), \
                           random.randint ( 0, 255 )

    # Horniness is used during mating to see whether the two potential mates are
    # "in the mood" to make babies. It changes every time the organism attempts
    # to mate, so there's no self.__horniness variable and there's no setter--
    # instead, you have to generate it every time
    @property
    def horniness ( self ):
        return random.randint ( 0, 100 )

    # Fertility determines whether this organism can mate or not.
    # An organism is fertile during the middle third of its lifespan, meaning
    # its life is more than one third over, and less than two thirds over
    @property
    def fertile ( self ):
        return self.age in range(int(self.longevity / 3),
                                 int(self.longevity / 1.5))

    @property
    def young ( self ):
        return self.age <= self.longevity / 3

    @property
    def old ( self ):
        return self.age >= self.longevity / 1.5 # same as (longevity / 3) * 2

    # The radius is calculated from the age versus the longevity, so it grows
    # from age 0 to the age of death
    @property
    def radius ( self ):
        radius = (self.age + .1) * MAX_RADIUS / self.longevity
        if radius + MIN_RADIUS < MAX_RADIUS:
            return int ( round ( radius + MIN_RADIUS ) )
        else:
            return int ( round ( radius ) )


    # Used during the initial population generation, this just chooses a random
    # location on the screen for the starting position of the organism
    def get_rect_pos ( self ):
        return random.randint ( 0, SCREEN_WIDTH ), \
               random.randint ( 0, SCREEN_HEIGHT )

    # Advance the organism's age by 1 generation, adjust activity level and size
    def become_older ( self ):
        self.age += AGING_PER_GENERATION
        if self.young: self.activity += 1
        elif self.old: self.activity -= 1

        if self.activity > 100:
            self.activity = 100
        elif self.activity < 0:
            self.activity = 0

        self.updateSize()

    # Attraction between two organisms is determined by how similar their
    # respective attraction levels are. If this function returns 0, that means
    # the organisms are attracted to each other
    def get_mutual_attraction ( self, potential_mate ):
       return abs ( self.attractiveness - potential_mate.attractiveness )

    # During the mating process, the attributes of each parent are combined to
    # create the attributes of the baby. The parent attributes are first
    # averaged, then a random value is added to simulate mutation
    def combine_genes ( self, gene1, gene2 ):
        gene_avg = (gene1 + gene2) / 2
        #get a random number between -1 and 1, like -.567867 or whatever
        mutation_factor = random.uniform ( -1, 1 )
        #mutate the averaged gene
        gene_avg += (gene_avg / 4) * mutation_factor

        return int ( round ( gene_avg ) )

    # Try to mate with a given organism. If both organisms are "horny", they
    # will mate
    def try_mate ( self, mate ):
        #if the organisms aren't both horny, then don't make a baby
        if self.horniness > self.activity or mate.horniness > mate.activity:
            return False

        #child_name       = '(%s_%s)' % (self.name, mate.name)

        # Yhe attributes of the child are basically rounded averages from the
        # parents plus or minus 50% of the average, for mutation. See the
        # comments for combine_genes for details
        child_longevity = self.combine_genes ( self.longevity,
                                               mate.longevity )
        child_attractiveness = self.combine_genes ( self.attractiveness,
                                                    mate.attractiveness )
        child_activity = self.combine_genes ( self.activity,
                                              mate.activity )
        child_pos = self.rect.left + (self.rect.width / 2) + self.radius, \
                    mate.rect.top + (self.rect.height / 2)

        child_r = self.combine_genes ( self.color[0], mate.color[0] )
        child_g = self.combine_genes ( self.color[1], mate.color[1] )
        child_b = self.combine_genes ( self.color[2], mate.color[2] )

        return Organism (
            self.render_group,
            #child_name,
            child_longevity,
            child_attractiveness,
            child_activity,
            (child_r, child_g, child_b),
            child_pos )

    # Get a new position for this organism
    def move ( self ):
        # new position will be at most the value of SPEED, in either direction
        movement = random.randint ( -SPEED, SPEED ), \
                   random.randint ( -SPEED, SPEED )
        # scale the movement according to this organism's activity level. For
        # example, if the calculated movement is 10 pixels upward, and the
        # activity level of the organism is 90, then we'll only go 90% of 10
        # pixels, which is 9 pixels
        movement = list(map(lambda x: math.ceil(x * self.activity / 100),
                            movement))

        # change the X position according to the calculated movement, then make
        # sure the new position is on the screen
        self.rect.left += movement[0]
        if self.rect.left > SCREEN_WIDTH:
            self.rect.left -= SCREEN_WIDTH
        elif self.rect.left < 0:
            self.rect.left += SCREEN_WIDTH

        # change the Y position according to the calculated movement, then make
        # sure the new position is on the screen
        self.rect.top += movement[1]
        if self.rect.top > SCREEN_HEIGHT:
            self.rect.top -= SCREEN_HEIGHT
        if self.rect.top < 0:
            self.rect.top += SCREEN_HEIGHT

    # this function draws the circle that represents the organism on screen
    def updateSize ( self ):
        self.image.fill(TRANSPARENT_COLOR)
        pygame.draw.circle ( self.image,
                             self.color,
                            (MAX_RADIUS, MAX_RADIUS),
                             self.radius)

# -----------------------------------------------------------
class Society(pygame.sprite.RenderPlain):
    ''' Responsible for keeping track of all the organisms within it,
    and responsible for facilitating their interactions
    '''
    def __init__ (self, initial_population_size):

        pygame.sprite.AbstractGroup.__init__(self )

        self.initial_population_size  = initial_population_size

        #generate a list of organisms is random characteristics
        self.organisms = []
        for i in range ( self.initial_population_size ):
            self.organisms.append(Organism (self))

    # Updates the age and position of old organisms, and makes baby organisms
    def update ( self ):
        # I keep track of which organisms died and which were born, because I
        # need to pass this information to the object that renders them, so that
        # the killed ones disappear and the news one appear
        killed_organisms = self.age_organisms ( )
        self.move_organisms ( )
        baby_organisms = self.mate_organisms ( )
        return killed_organisms, baby_organisms

    # Ages each of the organisms in the society, and then checks if they are are
    # too old to continue living. If they are too old, the society kills them
    def age_organisms ( self ):
        killed = []
        for organism in self.organisms:
            organism.become_older ( )
            if organism.age >= organism.longevity:
                killed.append(organism)
                self.kill ( organism )
        return killed

    # Moves each organism to a new position on the screen
    def move_organisms ( self ):
         for organism in self.organisms:
            organism.move ( )

    # Tries to find a mate for each organism in the society, and if one is
    # found, creates the new baby
    def mate_organisms ( self ):
        #if the population is at max, don't try to make more babies
        if len(self.organisms) > MAX_POPULATION:
            return []

        babies = []
        for organism in self.organisms:
            mate = self.find_mate ( organism )
            if mate != False:
                baby = self.do_mate ( organism, mate )
                if baby:
                    babies.append(baby)

        return babies

    # Normal collision functions will test for collision between an object and
    # itself, and that would allow an organism to mate with itself. Tempting,
    # but no
    def collide_organism(self, left, right):
       if left is not right:
           return pygame.sprite.collide_rect(left, right)

    # Finds nearby organisms, then determines if they are fertile, and if there
    # is mutual attraction. If there's an organism like that, return it to be a
    # potential mate (when they try to mate,they still have to be horny to make
    # a baby)
    def find_mate ( self, organism ):
        # Find an organism that's touching this one. She can only mate if she's
        # touching.
        potential_mate = pygame.sprite.spritecollideany(organism,
                                                        self.organisms,
                                                        self.collide_organism)

        # loop through each organism that's touching, and see if they are
        # fertile (not too young or too old) and whether there is mutual
        # attraction
        #for potential_mate in colliding_organisms:
        if potential_mate and potential_mate.fertile and \
           organism.get_mutual_attraction ( potential_mate ) == 0:
            return potential_mate

        # No fertile, attractive mate was found nearby, so return false
        return False


    # Make the two organisms try to mate. IF they mate, they will make a baby.
    # If there is a new baby, add it to the population
    def do_mate ( self, org1, org2 ):
        baby = org1.try_mate(org2)
        if baby != False:
            self.organisms.append(baby)

        return baby

    # "Kill" the given organism (because it's too old) by removing it from the
    # population list. Poor little guy is going to get garbage collected.
    def kill ( self, organism ):
        organism.kill()
        self.organisms.remove(organism)

def main ( ):

    pygame.init ( )
    pygame.display.set_caption('Organisms')

    screen = pygame.display.set_mode ( ( SCREEN_WIDTH, SCREEN_HEIGHT ) )
    screen.fill(BACKGROUND_COLOR)

    society = Society ( INITIAL_POPULATION_SIZE )

    clock = pygame.time.Clock ( )
    done = False
    while not done:
        society.update ()

        # All the things that have happened since the last update are in a
        # collection of events that we look at by calling pygame.event.get() --
        # in this loop we'll respond to the events that we care about
        for event in pygame.event.get():

            #Something happened to make the game end
            if event.type == pygame.QUIT:
                done = True

            # Quit if the player presses ESCAPE
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    done = True

        # Quit if all the organisms are dead
        if not len ( society.organisms ):
            done = True

        # If we don't fill up the screen with the background color first, then
        # all the organisms will be painted over top of where they used to be
        screen.fill(BACKGROUND_COLOR)

        # Now that the screen is blank again, we redraw the organisms in their
        # new locations
        society.draw(screen)

        # Now that we've updated everything and drawn it all, we display it to
        # the screen
        pygame.display.flip()

        clock.tick (FRAMERATE)

    # The main loop is over, now we can clean up pygame and exit the program
    pygame.quit()

# the reason we made a main() function and then we call it here at the bottom of
# the file is that we want the code to not break if it's included in a larger
# project. If this file is run on its own then __name__ will be "__main__", and
# that meanas we want to do all the stuff in the main() function. If it's
# included from another python program, then __name__ won't be main, and we
# don't want to run the code right away. This is just a safe way of doing it,
# and you'll see this pattern a lot in python
if __name__ == '__main__': main ( )
